--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit b8999284e467d3eb14422f9ba37bea954f9930fd
Parents : 9cd0e66
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-06-14T13:16:23-05:00
feat(announces): implement bulk query endpoint for fetching announces and optimize data retrieval in Network Visualiser
Changes
8 files changed, 536 insertions(+), 178 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index bb848d48..728cb378 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -9379,131 +9379,11 @@ class ReticulumMeshChat:
)
# pre-fetch icons and other data to avoid N+1 queries in convert_db_announce_to_dict
- other_user_hashes = [r["destination_hash"] for r in results]
- user_icons = {}
- if other_user_hashes:
-
- def _fetch_icons():
- return self.database.misc.get_user_icons(other_user_hashes)
-
- db_icons = await asyncio.to_thread(_fetch_icons)
- for icon in db_icons:
- user_icons[icon["destination_hash"]] = {
- "icon_name": icon["icon_name"],
- "foreground_colour": icon["foreground_colour"],
- "background_colour": icon["background_colour"],
- }
-
- # fetch custom display names
- custom_names = {}
- lxmf_names_for_telephony = {}
- if other_user_hashes:
-
- def _fetch_custom_names():
- return self.database.provider.fetchall(
- f"SELECT destination_hash, display_name FROM custom_destination_display_names WHERE destination_hash IN ({','.join(['?'] * len(other_user_hashes))})",
- other_user_hashes,
- )
-
- db_custom_names = await asyncio.to_thread(_fetch_custom_names)
- for row in db_custom_names:
- custom_names[row["destination_hash"]] = row["display_name"]
-
- # Pre-fetch LXMF display names by identity (telephony heard list).
- if aspect == "lxst.telephony":
- identity_hashes = list(
- {r["identity_hash"] for r in results if r.get("identity_hash")},
- )
- if identity_hashes:
-
- def _fetch_lxmf_names():
- return self.database.announces.provider.fetchall(
- f"SELECT identity_hash, app_data FROM announces WHERE aspect = 'lxmf.delivery' AND identity_hash IN ({','.join(['?'] * len(identity_hashes))})",
- identity_hashes,
- )
-
- lxmf_results = await asyncio.to_thread(_fetch_lxmf_names)
- for row in lxmf_results:
- lxmf_names_for_telephony[row["identity_hash"]] = (
- parse_lxmf_display_name(row["app_data"])
- )
-
- # process all announces
- all_announces = []
- for announce in results:
- # Optimized convert_db_announce_to_dict logic inline to use pre-fetched data
- if not isinstance(announce, dict):
- announce = dict(announce)
-
- # parse display name from announce
- display_name = None
- is_local = (
- self.current_context
- and announce["identity_hash"] == self.current_context.identity_hash
- )
-
- if announce["aspect"] == "lxmf.delivery":
- display_name = parse_lxmf_display_name(announce["app_data"])
- elif announce["aspect"] == "nomadnetwork.node":
- display_name = parse_nomadnetwork_node_display_name(
- announce["app_data"],
- )
- elif announce["aspect"] == "lxst.telephony":
- display_name = parse_lxmf_display_name(announce["app_data"])
- if not display_name or display_name == "Anonymous Peer":
- # Try pre-fetched LXMF name
- display_name = lxmf_names_for_telephony.get(
- announce["identity_hash"],
- )
- elif announce["aspect"] == "rrc.hub":
- display_name = rrc_protocol.display_name_from_hub_app_data(
- announce.get("app_data"),
- )
-
- if not display_name or display_name == "Anonymous Peer":
- if is_local and self.current_context:
- display_name = self.current_context.config.display_name.get()
- else:
- # try to resolve name from identity hash (checks contacts too)
- display_name = (
- self.get_name_for_identity_hash(announce["identity_hash"])
- or "Anonymous Peer"
- )
-
- hops = RNS.Transport.hops_to(
- bytes.fromhex(announce["destination_hash"]),
- )
-
- # ensure created_at and updated_at have Z suffix
- created_at = str(announce["created_at"])
- if created_at and "+" not in created_at and "Z" not in created_at:
- created_at += "Z"
- updated_at = str(announce["updated_at"])
- if updated_at and "+" not in updated_at and "Z" not in updated_at:
- updated_at += "Z"
-
- all_announces.append(
- {
- "id": announce["id"],
- "destination_hash": announce["destination_hash"],
- "aspect": announce["aspect"],
- "identity_hash": announce["identity_hash"],
- "identity_public_key": announce["identity_public_key"],
- "app_data": announce["app_data"],
- "hops": hops,
- "rssi": announce["rssi"],
- "snr": announce["snr"],
- "quality": announce["quality"],
- "created_at": created_at,
- "updated_at": updated_at,
- "display_name": display_name,
- "custom_display_name": custom_names.get(
- announce["destination_hash"],
- ),
- "lxmf_user_icon": user_icons.get(announce["destination_hash"]),
- "contact_image": announce.get("contact_image"),
- },
- )
+ all_announces = await asyncio.to_thread(
+ self._batch_convert_announces_to_api_dicts,
+ results,
+ aspect,
+ )
# apply search query filter if provided
if search_query:
@@ -9529,6 +9409,45 @@ class ReticulumMeshChat:
},
)
+ @routes.post("/api/v1/announces/query")
+ async def announces_query(request):
+ try:
+ data = await request.json()
+ except Exception:
+ data = {}
+ destination_hashes = data.get("destination_hashes")
+ aspects = data.get("aspects")
+ if not isinstance(destination_hashes, list) or not destination_hashes:
+ return web.json_response({"announces": [], "total_count": 0})
+ if not isinstance(aspects, list) or not aspects:
+ aspects = ["lxmf.delivery", "nomadnetwork.node"]
+
+ blocked_identity_hashes = None
+ if self.current_context and self.current_context.config:
+ blocked = await asyncio.to_thread(
+ self.database.misc.get_blocked_destinations,
+ )
+ blocked_identity_hashes = [b["destination_hash"] for b in blocked]
+
+ results = await asyncio.to_thread(
+ self.announce_manager.get_announces_for_destination_hashes,
+ destination_hashes=destination_hashes,
+ aspects=aspects,
+ blocked_identity_hashes=blocked_identity_hashes,
+ )
+ all_announces = await asyncio.to_thread(
+ self._batch_convert_announces_to_api_dicts,
+ results,
+ None,
+ False,
+ )
+ return web.json_response(
+ {
+ "announces": all_announces,
+ "total_count": len(all_announces),
+ },
+ )
+
# serve favourites
@routes.get("/api/v1/favourites")
async def favourites_get(request):
@@ -16452,6 +16371,122 @@ class ReticulumMeshChat:
# convert an lxmf message to a dictionary, for sending over websocket
# convert database announce to a dictionary
+ def _batch_convert_announces_to_api_dicts(
+ self, results, aspect=None, include_hops=True
+ ):
+ """Batch-convert announce rows using prefetched icons and custom names."""
+ if not results:
+ return []
+
+ other_user_hashes = [r["destination_hash"] for r in results]
+ user_icons = {}
+ if other_user_hashes:
+ db_icons = self.database.misc.get_user_icons(other_user_hashes)
+ for icon in db_icons:
+ user_icons[icon["destination_hash"]] = {
+ "icon_name": icon["icon_name"],
+ "foreground_colour": icon["foreground_colour"],
+ "background_colour": icon["background_colour"],
+ }
+
+ custom_names = {}
+ lxmf_names_for_telephony = {}
+ if other_user_hashes:
+ db_custom_names = self.database.provider.fetchall(
+ f"SELECT destination_hash, display_name FROM custom_destination_display_names WHERE destination_hash IN ({','.join(['?'] * len(other_user_hashes))})",
+ other_user_hashes,
+ )
+ for row in db_custom_names:
+ custom_names[row["destination_hash"]] = row["display_name"]
+
+ if aspect == "lxst.telephony":
+ identity_hashes = list(
+ {r["identity_hash"] for r in results if r.get("identity_hash")},
+ )
+ if identity_hashes:
+ lxmf_results = self.database.announces.provider.fetchall(
+ f"SELECT identity_hash, app_data FROM announces WHERE aspect = 'lxmf.delivery' AND identity_hash IN ({','.join(['?'] * len(identity_hashes))})",
+ identity_hashes,
+ )
+ for row in lxmf_results:
+ lxmf_names_for_telephony[row["identity_hash"]] = (
+ parse_lxmf_display_name(row["app_data"])
+ )
+
+ all_announces = []
+ for announce in results:
+ if not isinstance(announce, dict):
+ announce = dict(announce)
+
+ display_name = None
+ is_local = (
+ self.current_context
+ and announce["identity_hash"] == self.current_context.identity_hash
+ )
+
+ if announce["aspect"] == "lxmf.delivery":
+ display_name = parse_lxmf_display_name(announce["app_data"])
+ elif announce["aspect"] == "nomadnetwork.node":
+ display_name = parse_nomadnetwork_node_display_name(
+ announce["app_data"],
+ )
+ elif announce["aspect"] == "lxst.telephony":
+ display_name = parse_lxmf_display_name(announce["app_data"])
+ if not display_name or display_name == "Anonymous Peer":
+ display_name = lxmf_names_for_telephony.get(
+ announce["identity_hash"],
+ )
+ elif announce["aspect"] == "rrc.hub":
+ display_name = rrc_protocol.display_name_from_hub_app_data(
+ announce.get("app_data"),
+ )
+
+ if not display_name or display_name == "Anonymous Peer":
+ if is_local and self.current_context:
+ display_name = self.current_context.config.display_name.get()
+ else:
+ display_name = (
+ self.get_name_for_identity_hash(announce["identity_hash"])
+ or "Anonymous Peer"
+ )
+
+ hops = None
+ if include_hops:
+ hops = RNS.Transport.hops_to(
+ bytes.fromhex(announce["destination_hash"]),
+ )
+
+ created_at = str(announce["created_at"])
+ if created_at and "+" not in created_at and "Z" not in created_at:
+ created_at += "Z"
+ updated_at = str(announce["updated_at"])
+ if updated_at and "+" not in updated_at and "Z" not in updated_at:
+ updated_at += "Z"
+
+ all_announces.append(
+ {
+ "id": announce["id"],
+ "destination_hash": announce["destination_hash"],
+ "aspect": announce["aspect"],
+ "identity_hash": announce["identity_hash"],
+ "identity_public_key": announce["identity_public_key"],
+ "app_data": announce["app_data"],
+ "hops": hops,
+ "rssi": announce["rssi"],
+ "snr": announce["snr"],
+ "quality": announce["quality"],
+ "created_at": created_at,
+ "updated_at": updated_at,
+ "display_name": display_name,
+ "custom_display_name": custom_names.get(
+ announce["destination_hash"],
+ ),
+ "lxmf_user_icon": user_icons.get(announce["destination_hash"]),
+ "contact_image": announce.get("contact_image"),
+ },
+ )
+ return all_announces
+
def convert_db_announce_to_dict(self, announce):
# convert to dict if it's a sqlite3.Row
if not isinstance(announce, dict):
diff --git a/meshchatx/src/backend/announce_manager.py b/meshchatx/src/backend/announce_manager.py
index d80a521d..21bdda57 100644
--- a/meshchatx/src/backend/announce_manager.py
+++ b/meshchatx/src/backend/announce_manager.py
@@ -202,6 +202,59 @@ class AnnounceManager:
result = self.db.provider.fetchone(sql, params)
return result["count"] if result else 0
+ def get_announces_for_destination_hashes(
+ self,
+ destination_hashes,
+ aspects=None,
+ blocked_identity_hashes=None,
+ ):
+ """Return announce rows for many destination hashes (visualiser bulk query)."""
+ if not destination_hashes:
+ return []
+ aspect_list = aspects or ["lxmf.delivery", "nomadnetwork.node"]
+ hash_list = []
+ seen = set()
+ for raw in destination_hashes:
+ if not isinstance(raw, str):
+ continue
+ h = raw.lower().strip()
+ if not h or h in seen:
+ continue
+ seen.add(h)
+ hash_list.append(h)
+ if not hash_list:
+ return []
+
+ chunk_size = 400
+ out = []
+ for aspect in aspect_list:
+ if not isinstance(aspect, str) or not aspect:
+ continue
+ for offset in range(0, len(hash_list), chunk_size):
+ chunk = hash_list[offset : offset + chunk_size]
+ placeholders = ", ".join(["?"] * len(chunk))
+ sql = f"""
+ SELECT a.*, c.custom_image as contact_image
+ FROM announces a
+ LEFT JOIN contacts c ON (
+ a.identity_hash = c.remote_identity_hash OR
+ a.destination_hash = c.lxmf_address OR
+ a.destination_hash = c.lxst_address
+ )
+ WHERE a.aspect = ?
+ AND a.destination_hash IN ({placeholders})
+ """
+ params = [aspect, *chunk]
+ if blocked_identity_hashes:
+ blocked_placeholders = ", ".join(
+ ["?"] * len(blocked_identity_hashes)
+ )
+ sql += f" AND a.identity_hash NOT IN ({blocked_placeholders})"
+ params.extend(blocked_identity_hashes)
+ sql += " ORDER BY a.updated_at DESC"
+ out.extend(self.db.provider.fetchall(sql, params))
+ return out
+
def filter_announced_dicts_by_search_query(
items: list[dict],
diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index 952a105b..486981a9 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -50,6 +50,13 @@ import GlobalEmitter from "../../js/GlobalEmitter";
import NetworkVisualiserLoadingOverlay from "./internal/NetworkVisualiserLoadingOverlay.vue";
import NetworkVisualiserToolbar from "./internal/NetworkVisualiserToolbar.vue";
import NetworkVisualiserLegend from "./internal/NetworkVisualiserLegend.vue";
+import {
+ ANNOUNCE_HASH_CHUNK_SIZE,
+ VIZ_ANNOUNCE_ASPECTS,
+ dedupeIconQueueEntries,
+ pathHashesWithinHopFilter,
+ pickAdaptiveFetchConcurrency,
+} from "../../js/networkVisualiserPerf.js";
const HOP_MAX_FILTER_STORAGE_KEY = "meshchatx.visualiser.maxHops";
@@ -158,9 +165,11 @@ export default {
currentLOD: "high",
didDisableStabilization: false,
vizChunkSize: pickAdaptiveChunkSize(),
+ pathFetchConcurrency: pickAdaptiveFetchConcurrency(),
iconQueue: [],
iconQueueRunning: false,
iconQueueGeneration: 0,
+ lodRafId: null,
};
},
computed: {
@@ -189,8 +198,9 @@ export default {
},
hopMaxFilter() {
if (this.hopFilterDebounceTimer) clearTimeout(this.hopFilterDebounceTimer);
- this.hopFilterDebounceTimer = setTimeout(() => {
+ this.hopFilterDebounceTimer = setTimeout(async () => {
this.hopFilterDebounceTimer = null;
+ await this.ensureAnnouncesForPathHashes();
this.processVisualization();
}, 80);
},
@@ -209,6 +219,10 @@ export default {
clearTimeout(this.hopFilterDebounceTimer);
this.hopFilterDebounceTimer = null;
}
+ if (this.lodRafId != null) {
+ cancelAnimationFrame(this.lodRafId);
+ this.lodRafId = null;
+ }
if (this.network) {
this.network.destroy();
}
@@ -290,7 +304,7 @@ export default {
this.pathTable.push(...firstResp.data.path_table);
const totalCount = firstResp.data.total_count;
if (totalCount > this.pageSize) {
- const concurrency = 3;
+ const concurrency = this.pathFetchConcurrency;
for (let offset = this.pageSize; offset < totalCount; offset += this.pageSize * concurrency) {
if (this.abortController.signal.aborted) return;
const chunk = [];
@@ -316,33 +330,56 @@ export default {
console.error("Failed to fetch path table batch", e);
}
},
- async getAnnouncesBatch() {
- this.announces = {};
- const aspectsToFetch = ["lxmf.delivery", "nomadnetwork.node"];
- try {
- for (const aspect of aspectsToFetch) {
- if (this.abortController.signal.aborted) return;
- this.loadingStatus = `Loading ${aspect}...`;
- let offset = 0;
- let hasMore = true;
- while (hasMore) {
- const resp = await window.api.get(`/api/v1/announces`, {
- params: { aspect, limit: this.pageSize, offset },
- signal: this.abortController.signal,
- });
- for (const announce of resp.data.announces) {
+ async fetchAnnouncesForHashes(hashes) {
+ if (!Array.isArray(hashes) || hashes.length === 0) {
+ return;
+ }
+ const concurrency = this.pathFetchConcurrency;
+ for (let i = 0; i < hashes.length; i += ANNOUNCE_HASH_CHUNK_SIZE * concurrency) {
+ if (this.abortController.signal.aborted) return;
+ const offsets = [];
+ for (let j = 0; j < concurrency && i + j * ANNOUNCE_HASH_CHUNK_SIZE < hashes.length; j++) {
+ offsets.push(i + j * ANNOUNCE_HASH_CHUNK_SIZE);
+ }
+ const promises = offsets.map((start) => {
+ const chunk = hashes.slice(start, start + ANNOUNCE_HASH_CHUNK_SIZE);
+ return window.api.post(
+ "/api/v1/announces/query",
+ {
+ destination_hashes: chunk,
+ aspects: VIZ_ANNOUNCE_ASPECTS,
+ },
+ { signal: this.abortController.signal }
+ );
+ });
+ const responses = await Promise.all(promises);
+ for (const resp of responses) {
+ for (const announce of resp.data?.announces || []) {
+ if (announce?.destination_hash) {
this.announces[announce.destination_hash] = announce;
}
- const loaded = Object.keys(this.announces).length;
- const total = resp.data.total_count;
- this.loadingStatus = `Loading announces (${loaded})`;
- offset += resp.data.announces.length;
- hasMore = resp.data.announces.length === this.pageSize && offset < total;
}
}
- } catch (e) {
- if (window.api.isCancel(e)) return;
- console.error("Failed to fetch announces batch", e);
+ this.loadingStatus = `Loading announces (${Object.keys(this.announces).length})`;
+ }
+ },
+ async ensureAnnouncesForPathHashes({ reset = false } = {}) {
+ const needed = pathHashesWithinHopFilter(this.pathTable, this.hopMaxFilter);
+ if (reset) {
+ this.announces = {};
+ }
+ const missing = needed.filter((hash) => !this.announces[hash]);
+ if (missing.length > 0) {
+ this.loadingStatus = "Loading announces...";
+ await this.fetchAnnouncesForHashes(missing);
+ }
+ if (reset && needed.length > 0) {
+ const neededSet = new Set(needed);
+ for (const hash of Object.keys(this.announces)) {
+ if (!neededSet.has(hash)) {
+ delete this.announces[hash];
+ }
+ }
}
},
async getConfig() {
@@ -614,7 +651,7 @@ export default {
this.refreshPhysicsEnabled();
this.network.on("zoom", () => {
- this.updateLOD();
+ this.scheduleUpdateLOD();
});
await this.manualUpdate();
@@ -642,6 +679,15 @@ export default {
this.isUpdating = false;
}
},
+ scheduleUpdateLOD() {
+ if (this.lodRafId != null) {
+ cancelAnimationFrame(this.lodRafId);
+ }
+ this.lodRafId = requestAnimationFrame(() => {
+ this.lodRafId = null;
+ this.updateLOD();
+ });
+ },
updateLOD() {
if (!this.network) return;
if (typeof this.network.getScale !== "function") return;
@@ -661,6 +707,10 @@ export default {
return this.getNodeLODProps(node, newLOD);
});
this.nodes.update(updates);
+
+ if (newLOD === "high" && this.iconQueue.length > 0) {
+ this.scheduleIconQueue();
+ }
},
nodeColor(border, background) {
return {
@@ -716,9 +766,9 @@ export default {
if (this.abortController.signal.aborted) return;
this.loadingStatus = "Fetching network data...";
- await this.getAnnouncesBatch();
+ await this.getPathTableBatch();
if (this.abortController.signal.aborted) return;
- await this.getPathTableBatch(Object.keys(this.announces));
+ await this.ensureAnnouncesForPathHashes({ reset: true });
if (this.abortController.signal.aborted) return;
await this.processVisualization();
@@ -936,7 +986,6 @@ export default {
if (discoveredNodes.length > 0) this.nodes.update(discoveredNodes);
if (discoveredEdges.length > 0) this.edges.update(discoveredEdges);
- await this.$nextTick();
if (this.abortController.signal.aborted) return;
// Process path table in batches to prevent UI block
@@ -1038,15 +1087,17 @@ export default {
entry.hops === 1
? "/assets/images/network-visualiser/user_1hop.png"
: "/assets/images/network-visualiser/user.png";
- this.iconQueue.push({
- nodeId: node.id,
- cacheKey,
- iconName: conversation.lxmf_user_icon.icon_name,
- fg: conversation.lxmf_user_icon.foreground_colour,
- bg: conversation.lxmf_user_icon.background_colour,
- size: 64,
- generation: this.iconQueueGeneration,
- });
+ if (this.currentLOD !== "low") {
+ this.iconQueue.push({
+ nodeId: node.id,
+ cacheKey,
+ iconName: conversation.lxmf_user_icon.icon_name,
+ fg: conversation.lxmf_user_icon.foreground_colour,
+ bg: conversation.lxmf_user_icon.background_colour,
+ size: 64,
+ generation: this.iconQueueGeneration,
+ });
+ }
}
node.size = 30;
node._originalSize = 30;
@@ -1143,7 +1194,23 @@ export default {
this.network.setOptions({ physics: { enabled: this.enablePhysics } });
}
- this.runIconQueue();
+ this.scheduleIconQueue();
+ },
+ scheduleIconQueue() {
+ if (this.currentLOD === "low" || this.iconQueue.length === 0) {
+ return;
+ }
+ if (this.iconQueueRunning) {
+ return;
+ }
+ const run = () => {
+ this.runIconQueue();
+ };
+ if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(run, { timeout: 1500 });
+ } else {
+ run();
+ }
},
/*
* Drains the deferred lxmf custom-icon queue. Runs sequentially with
@@ -1153,36 +1220,40 @@ export default {
* we were running) are skipped, as are nodes that no longer exist.
*/
async runIconQueue() {
- if (this.iconQueueRunning) return;
+ if (this.iconQueueRunning || this.currentLOD === "low") return;
this.iconQueueRunning = true;
try {
- while (this.iconQueue.length > 0) {
+ const work = dedupeIconQueueEntries(this.iconQueue);
+ this.iconQueue = [];
+ for (const item of work) {
if (this.abortController.signal.aborted) return;
- const item = this.iconQueue.shift();
if (item.generation !== this.iconQueueGeneration) {
continue;
}
- if (!this.nodes.get(item.nodeId)) {
- continue;
- }
- /*
- * Queue items can collapse onto a single cached icon: if
- * a previous iteration already painted this cacheKey we
- * can short-circuit instead of re-invoking createIconImage
- * (which would also redo the canvas+SVG decode work).
- */
let url = this.iconCache[item.cacheKey];
if (!url) {
url = await this.createIconImage(item.iconName, item.fg, item.bg, item.size);
if (this.abortController.signal.aborted) return;
}
- if (url && this.nodes.get(item.nodeId)) {
- this.nodes.update({ id: item.nodeId, image: url });
+ if (!url) {
+ continue;
+ }
+ const updates = [];
+ for (const nodeId of item.nodeIds) {
+ if (this.nodes.get(nodeId)) {
+ updates.push({ id: nodeId, image: url });
+ }
+ }
+ if (updates.length > 0) {
+ this.nodes.update(updates);
}
await yieldToMain();
}
} finally {
this.iconQueueRunning = false;
+ if (this.iconQueue.length > 0 && this.currentLOD !== "low") {
+ this.scheduleIconQueue();
+ }
}
},
},
diff --git a/meshchatx/src/frontend/js/networkVisualiserPerf.js b/meshchatx/src/frontend/js/networkVisualiserPerf.js
new file mode 100644
index 00000000..2fcecf97
--- /dev/null
+++ b/meshchatx/src/frontend/js/networkVisualiserPerf.js
@@ -0,0 +1,80 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
+export const VIZ_ANNOUNCE_ASPECTS = ["lxmf.delivery", "nomadnetwork.node"];
+
+export const ANNOUNCE_HASH_CHUNK_SIZE = 500;
+
+/**
+ * @param {unknown[]} pathTable
+ * @param {number|null|undefined} hopMax
+ * @returns {string[]}
+ */
+export function pathHashesWithinHopFilter(pathTable, hopMax) {
+ if (!Array.isArray(pathTable) || pathTable.length === 0) {
+ return [];
+ }
+ const out = new Set();
+ for (const entry of pathTable) {
+ if (!entry || typeof entry !== "object") {
+ continue;
+ }
+ const hops = entry.hops;
+ if (hops == null) {
+ continue;
+ }
+ if (hopMax != null && hops > hopMax) {
+ continue;
+ }
+ const hash = entry.hash;
+ if (typeof hash === "string" && hash) {
+ out.add(hash);
+ }
+ }
+ return Array.from(out);
+}
+
+/**
+ * Collapse deferred icon work so each unique cacheKey is painted once.
+ * @param {unknown[]} queue
+ * @returns {{ cacheKey: string, nodeIds: string[], iconName: string, fg: string, bg: string, size: number, generation: number }[]}
+ */
+export function dedupeIconQueueEntries(queue) {
+ if (!Array.isArray(queue) || queue.length === 0) {
+ return [];
+ }
+ const byKey = new Map();
+ for (const item of queue) {
+ if (!item || typeof item !== "object" || !item.cacheKey || !item.nodeId) {
+ continue;
+ }
+ let bucket = byKey.get(item.cacheKey);
+ if (!bucket) {
+ bucket = {
+ cacheKey: item.cacheKey,
+ nodeIds: [],
+ iconName: item.iconName,
+ fg: item.fg,
+ bg: item.bg,
+ size: item.size,
+ generation: item.generation,
+ };
+ byKey.set(item.cacheKey, bucket);
+ }
+ if (!bucket.nodeIds.includes(item.nodeId)) {
+ bucket.nodeIds.push(item.nodeId);
+ }
+ }
+ return Array.from(byKey.values());
+}
+
+/**
+ * Parallel path/announce fetch concurrency scaled to hardware.
+ * @returns {number}
+ */
+export function pickAdaptiveFetchConcurrency() {
+ const cores = (typeof navigator !== "undefined" && navigator.hardwareConcurrency) || 4;
+ if (cores <= 2) return 2;
+ if (cores <= 4) return 3;
+ if (cores <= 6) return 4;
+ return 6;
+}
diff --git a/tests/backend/test_announce_manager_extended.py b/tests/backend/test_announce_manager_extended.py
index 7e5cf784..720014e8 100644
--- a/tests/backend/test_announce_manager_extended.py
+++ b/tests/backend/test_announce_manager_extended.py
@@ -147,3 +147,24 @@ def test_get_filtered_announces_all_fields(mock_db):
assert "a.identity_hash NOT IN (?, ?)" in sql
assert 10 in params
assert 20 in params
+
+
+def test_get_announces_for_destination_hashes_chunks_and_filters(mock_db):
+ manager = AnnounceManager(mock_db)
+ mock_db.provider.fetchall.side_effect = [
+ [{"destination_hash": "aa", "aspect": "lxmf.delivery"}],
+ [{"destination_hash": "bb", "aspect": "nomadnetwork.node"}],
+ ]
+ hashes = ["AA", "bb", "aa"]
+ out = manager.get_announces_for_destination_hashes(
+ hashes,
+ aspects=["lxmf.delivery", "nomadnetwork.node"],
+ blocked_identity_hashes=["blocked"],
+ )
+ assert len(out) == 2
+ assert mock_db.provider.fetchall.call_count == 2
+ first_sql, first_params = mock_db.provider.fetchall.call_args_list[0][0]
+ assert "a.destination_hash IN (?, ?)" in first_sql
+ assert "aa" in first_params
+ assert "bb" in first_params
+ assert "blocked" in first_params
diff --git a/tests/frontend/NetworkVisualiser.test.js b/tests/frontend/NetworkVisualiser.test.js
index 7d3a8563..d2275275 100644
--- a/tests/frontend/NetworkVisualiser.test.js
+++ b/tests/frontend/NetworkVisualiser.test.js
@@ -117,6 +117,21 @@ describe("NetworkVisualiser.vue", () => {
data: { path_table: [{ hash: "node1", interface: "eth0", hops: 1 }], total_count: 1 },
});
}
+ if (url.includes("/api/v1/announces/query")) {
+ return Promise.resolve({
+ data: {
+ announces: [
+ {
+ destination_hash: "node1",
+ aspect: "lxmf.delivery",
+ display_name: "Remote Node",
+ updated_at: new Date().toISOString(),
+ },
+ ],
+ total_count: 1,
+ },
+ });
+ }
return Promise.resolve({ data: {} });
}),
isCancel: vi.fn().mockReturnValue(false),
diff --git a/tests/frontend/VisualizerOptimization.test.js b/tests/frontend/VisualizerOptimization.test.js
index 510f3b80..c6926779 100644
--- a/tests/frontend/VisualizerOptimization.test.js
+++ b/tests/frontend/VisualizerOptimization.test.js
@@ -59,6 +59,12 @@ describe("NetworkVisualiser Optimization and Abort", () => {
return Promise.resolve({ data: { announces: [], total_count: 0 } });
return Promise.resolve({ data: {} });
}),
+ post: vi.fn().mockImplementation((url) => {
+ if (url.includes("/api/v1/announces/query")) {
+ return Promise.resolve({ data: { announces: [], total_count: 0 } });
+ }
+ return Promise.resolve({ data: {} });
+ }),
isCancel: vi.fn().mockImplementation((e) => e && e.name === "AbortError"),
};
window.api = axiosMock;
@@ -236,6 +242,42 @@ describe("NetworkVisualiser Optimization and Abort", () => {
expect(end - start).toBeLessThan(100); // Should be very fast
});
+ it("fetches announces via bulk query endpoint", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+ wrapper.vm.pathTable = [
+ { hash: "aa", interface: "eth0", hops: 1 },
+ { hash: "bb", interface: "eth0", hops: 2 },
+ ];
+
+ axiosMock.post.mockImplementation((url) => {
+ if (url.includes("/api/v1/announces/query")) {
+ return Promise.resolve({
+ data: {
+ announces: [
+ {
+ destination_hash: "aa",
+ aspect: "lxmf.delivery",
+ display_name: "A",
+ updated_at: new Date().toISOString(),
+ },
+ ],
+ total_count: 1,
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ await wrapper.vm.ensureAnnouncesForPathHashes({ reset: true });
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/announces/query",
+ expect.objectContaining({ destination_hashes: expect.arrayContaining(["aa", "bb"]) }),
+ expect.any(Object)
+ );
+ expect(wrapper.vm.announces.aa).toBeTruthy();
+ });
+
it("reuses one cached icon for 500 nodes with identical lxmf_user_icon", async () => {
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
const wrapper = mountVisualiser();
diff --git a/tests/frontend/networkVisualiserPerf.test.js b/tests/frontend/networkVisualiserPerf.test.js
new file mode 100644
index 00000000..1a240eea
--- /dev/null
+++ b/tests/frontend/networkVisualiserPerf.test.js
@@ -0,0 +1,41 @@
+import { describe, it, expect } from "vitest";
+import {
+ ANNOUNCE_HASH_CHUNK_SIZE,
+ VIZ_ANNOUNCE_ASPECTS,
+ dedupeIconQueueEntries,
+ pathHashesWithinHopFilter,
+ pickAdaptiveFetchConcurrency,
+} from "@/js/networkVisualiserPerf.js";
+
+describe("networkVisualiserPerf", () => {
+ it("exports visualiser constants", () => {
+ expect(VIZ_ANNOUNCE_ASPECTS).toEqual(["lxmf.delivery", "nomadnetwork.node"]);
+ expect(ANNOUNCE_HASH_CHUNK_SIZE).toBe(500);
+ });
+
+ it("pathHashesWithinHopFilter respects hop max", () => {
+ const pathTable = [
+ { hash: "aa", hops: 1 },
+ { hash: "bb", hops: 4 },
+ { hash: "cc", hops: 5 },
+ { hash: "dd", hops: null },
+ ];
+ expect(pathHashesWithinHopFilter(pathTable, 4).sort()).toEqual(["aa", "bb"]);
+ expect(pathHashesWithinHopFilter(pathTable, null).sort()).toEqual(["aa", "bb", "cc"]);
+ });
+
+ it("dedupeIconQueueEntries collapses duplicate cache keys", () => {
+ const queue = [
+ { nodeId: "n1", cacheKey: "k1", iconName: "a", fg: "#000", bg: "#fff", size: 64, generation: 1 },
+ { nodeId: "n2", cacheKey: "k1", iconName: "a", fg: "#000", bg: "#fff", size: 64, generation: 1 },
+ { nodeId: "n3", cacheKey: "k2", iconName: "b", fg: "#111", bg: "#eee", size: 64, generation: 1 },
+ ];
+ const out = dedupeIconQueueEntries(queue);
+ expect(out).toHaveLength(2);
+ expect(out.find((x) => x.cacheKey === "k1")?.nodeIds).toEqual(["n1", "n2"]);
+ });
+
+ it("pickAdaptiveFetchConcurrency returns a positive integer", () => {
+ expect(pickAdaptiveFetchConcurrency()).toBeGreaterThanOrEqual(2);
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────